feat: explicit proxy configuration for the CLI - #698
NickJosevski wants to merge 11 commits into
Conversation
| } | ||
|
|
||
| http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} | ||
| transport, err := NewHttpTransport(ProxySettingsFromConfig(), true) |
There was a problem hiding this comment.
TLS verification is unconditionally disabled for all API traffic. insecureSkipVerify is hardcoded true at this call site, so the CLI never verifies the Octopus server certificate and --ignore-ssl-errors is effectively always on (the PR description acknowledges this in open question 6). Now that the insecure flag is an explicit parameter, this is the natural moment to plumb the real setting through (default false) or at minimum open the follow-up issue before merging — a MITM on any network path to the server can silently capture the API key sent on every request.
There was a problem hiding this comment.
Actioned in 884abe2. Confirmed the finding first: on this branch NewClientFactoryFromConfig passed a literal true, so every *http.Transport the factory built carried InsecureSkipVerify: true and no command verified the server certificate.
Before: verification off for all API traffic, unconditionally, with no way to turn it on. After: verification on, and turning it off is an explicit opt-in via one of
OCTOPUS_IGNORE_SSL_ERRORS=trueoctopus config set IgnoreSslErrors trueoctopus login --ignore-ssl-errors(one login only)
Plumbing, rather than just deleting the true: new IgnoreSslErrors config key defaulted to false in setDefaults, bound to OCTOPUS_IGNORE_SSL_ERRORS in bindEnvironment, read at the call site through apiclient.IgnoreSslErrorsFromConfig(). config set rejects a non-boolean value for it, because viper reads yes back as false and a user who thinks they turned verification off deserves an error rather than a setting that silently does nothing. The key is also listed by config list -f json and offered by the config get/config set pickers.
Tests: TestNewClientFactoryFromConfig_TlsVerification (table: default, explicit false, true, "true") asserts on the transport the factory actually hands out; TestSetup_DefaultsToVerifyingTheServerCertificate and TestSetup_BindsTheIgnoreSslErrorsEnvironmentVariable cover the key itself.
Verified end to end with a binary built from 884abe2 against https://self-signed.badssl.com:
default: tls: failed to verify certificate: x509: certificate signed by unknown authority
OCTOPUS_IGNORE_SSL_ERRORS=true: invalid character '<' looking for beginning of value (past TLS, failing on the HTML body)
This is a breaking change and it is deliberate — the whole point is that the old behaviour was silently insecure. Anyone on a self-signed or internal-CA certificate who is not currently opted out will start getting x509: certificate signed by unknown authority after upgrading. The README section I added says exactly that, recommends fixing the trust store first, and flags the opt-out as trust-the-network-path only. Worth calling out in the release notes; your call whether that is enough or whether this needs to land on its own release boundary.
| configData.Host = configFile.GetString(key) | ||
| case strings.ToLower(constants.ConfigNoPrompt): | ||
| configData.NoPrompt = configFile.GetString(key) | ||
| case strings.ToLower(constants.ConfigProxyUrl): |
There was a problem hiding this comment.
While extending this switch: config list -f json currently hard-errors for anyone who has run octopus login, because both loginWithApiKey and loginWithOpenIdConnect always write AccessToken to the config file (even as ""), and accesstoken has no case here (nor a ConfigData field) so it falls into default: return fmt.Errorf(...). Verified empirically on this branch: a config file containing accesstoken makes listRun return the key 'accesstoken' is not a supported config option and print nothing. ShowOctopus hits the same default. Pre-existing, but this PR touches the switch and adds a masked accesstoken entry via configFile.Set(constants.ConfigAccessToken, "***") above, so it is worth fixing here or in a fast follow.
There was a problem hiding this comment.
Actioned in 82f320d, extended in 884abe2.
The finding holds as written. On origin/main the ConfigData struct has no AccessToken and no ShowOctopus field and the switch has neither case, so both keys fall into default: — accesstoken is written by every octopus login (as "" for the OIDC/api-key path that does not use it), which is what made it hit everyone who had logged in.
82f320d adds the accesstoken and showoctopus cases and fields. 884abe2 adds ignoresslerrors alongside them, since that key is new in this PR and would otherwise have reintroduced exactly the same failure the moment someone set it.
Verified with a binary built from 884abe2 against a config file containing accesstoken, apikey, ignoresslerrors, proxyurl and url:
{
"accesstoken": "***",
"apikey": "***",
"editor": "",
"host": "https://octopus.example.com",
"ignoresslerrors": "true",
"noprompt": "",
"outputformat": "",
"proxyurl": "http://octo:[email protected]:3128",
"showoctopus": "",
"space": ""
}exit 0, where the same file on main returns the key 'accesstoken' is not a supported config option.
Residual, unchanged: the default: return fmt.Errorf(...) is still there, so the next config key anyone adds without touching this switch breaks -f json the same way. Enumerating ConfigData from the constants, or skipping unknown keys instead of erroring, would make that structural — out of scope here, and it needs a call on whether an unknown key in the file should be an error at all.
| // a configured client already carries a proxy-aware transport, so only the ssl | ||
| // override needs applying. Any other transport belongs to a caller (tests mock one | ||
| // in here) and is left alone. | ||
| if spinnerRoundTripper, ok := httpClient.Transport.(*apiclient.SpinnerRoundTripper); ok && ignoreSslErrors { |
There was a problem hiding this comment.
Behavior narrowing vs the old code: the removed code applied InsecureSkipVerify to any non-nil client, including one with a nil Transport (if httpClient.Transport == nil { httpClient.Transport = &http.Transport{} }). The new code only handles *apiclient.SpinnerRoundTripper; for a non-nil client with a nil or any other transport, --ignore-ssl-errors is now silently dropped (the new test even enshrines this). Unreachable via NewClientFactoryFromConfig/the stub today, but any factory implementation that returns a plain &http.Client{} gets a flag that does nothing, with no warning. Consider handling httpClient.Transport == nil explicitly (build the proxy-aware transport, as the nil-client branch does) so the previously-supported case keeps working.
There was a problem hiding this comment.
Actioned in 4492488.
The narrowing was real: the removed code did if httpClient.Transport == nil { httpClient.Transport = &http.Transport{} } before setting TLSClientConfig, and the first version of ConfigureHttpClient only matched *apiclient.SpinnerRoundTripper, so a non-nil client with a nil transport silently lost --ignore-ssl-errors (and got http.DefaultTransport, which knows nothing about the CLI's proxy settings either — so it lost the proxy too, which is arguably the worse half).
ConfigureHttpClient now has an explicit httpClient.Transport == nil branch that builds the proxy-aware transport, the same way the nil-client branch does, and assigns it into the caller's client. The test that enshrined the old behaviour is replaced by TestConfigureHttpClient/"gives a client with no transport a proxy aware one", which asserts both halves: InsecureSkipVerify is set, and transport.Proxy resolves to the configured proxy.
Transport ordering after the change: nil client → build; nil transport → build and assign; *SpinnerRoundTripper → rebuild Next; anything else → left alone (covered by TestConfigureHttpClient/"leaves a transport it does not own alone", which is the mock case).
| constants.ConfigShowOctopus, | ||
| constants.ConfigEditor, | ||
| // constants.ConfigProxyUrl, | ||
| constants.ConfigProxyUrl, |
There was a problem hiding this comment.
octopus config get ProxyUrl (now offered in this interactive picker) prints the value raw via configFile.GetString(key) — including an embedded user:password. That contradicts the redaction added to config list and the PR's stated invariant that the password is never echoed. It matches the existing (also unredacted) config get ApiKey precedent, but since this PR adds the redaction machinery, consider running the value through apiclient.RedactProxyUrl in getRun when the key is ProxyUrl.
There was a problem hiding this comment.
Actioned in 0ac4906. getRun now runs the value through apiclient.RedactProxyUrl when the key is ProxyUrl (matched with strings.EqualFold, since the key arrives in whatever case the user typed).
Verified with a binary built from 884abe2, config file holding "proxyurl": "http://octo:[email protected]:3128":
$ octopus config get ProxyUrl --no-prompt
http://octo:[email protected]:3128
Deliberately not extended to ApiKey/AccessToken: config get ApiKey printing the key raw is existing behaviour that something may well be scripted against, and changing it is a separate decision from "this PR should not add a new way to leak a secret".
| // applyCredentials adds the configured proxy credentials, unless the proxy url | ||
| // already carries its own. | ||
| func (s ProxySettings) applyCredentials(proxyUrl *url.URL) *url.URL { | ||
| if s.Username == "" || proxyUrl.User != nil { |
There was a problem hiding this comment.
Silent failure mode: if OCTOPUS_PROXY_PASSWORD is set but OCTOPUS_PROXY_USERNAME is empty (unset, or typo'd var name), the credentials are dropped with no diagnostic — the request goes to the proxy unauthenticated and the user gets a bare 407 with no hint that the CLI ignored their password. Consider warning (or erroring) when Password != "" && Username == "" in ProxySettingsFromConfig/ProxyFunc.
There was a problem hiding this comment.
Actioned in a5ee140, as an error rather than a warning.
Placed in the returned ProxyFunc closure rather than in ProxySettingsFromConfig, so it only fires when it actually matters — a proxy is in play for this request and the proxy url carries no credentials of its own:
if s.Password != "" && s.Username == "" && proxyUrl.User == nil {
return nil, fmt.Errorf("%s is set but %s is empty, so the proxy credentials cannot be used", ...)
}A stray OCTOPUS_PROXY_PASSWORD left in someone's shell profile therefore does not break a direct connection or a NO_PROXY-matched host, and it does not break the case where the password came from the url itself.
Error over warning because there is no good reading of it: the user asked for authenticated proxying, and the alternative is a 407 whose cause is invisible. It names both variables so a typo'd OCTOPUS_PROXY_USERNAME is self-diagnosing.
Tests: TestProxySettings_ProxyFuncRejectsAPasswordWithNoUsername and TestProxySettings_ProxyFuncIgnoresAPasswordWithNoProxy. Verified end to end with a binary built from 884abe2:
$ OCTOPUS_PROXY=http://proxy.invalid:3128 OCTOPUS_PROXY_PASSWORD=s3cret octopus space list --no-prompt
cannot get endpoint https://octopus.example.com/api/ from server. failure from http client
Get "https://octopus.example.com/api/": OCTOPUS_PROXY_PASSWORD is set but OCTOPUS_PROXY_USERNAME is empty, so the proxy credentials cannot be used
so it does survive the SDK's error wrapping and reaches the terminal.
| if err != nil { | ||
| return nil, err | ||
| } | ||
| spinnerRoundTripper.Next = transport |
There was a problem hiding this comment.
Two smaller notes on this branch: (1) as long as client_factory.go:123 hardcodes insecureSkipVerify=true, spinnerRoundTripper.Next is already an insecure proxy-aware transport, so this rebuild is a functional no-op that only resets the connection pool — it becomes meaningful once the factory plumbs the real setting (fine to keep for that reason, but worth a comment saying so). (2) It mutates the factory's shared client in place, so after login --ignore-ssl-errors every subsequent request in the process skips TLS verification, not just the login probe — intended for a one-shot CLI, but a trap for any longer-lived embedding.
There was a problem hiding this comment.
Both notes were correct. (1) is now obsolete and (2) is documented rather than changed.
(1) — resolved by 884abe2. The premise was the hardcoded insecureSkipVerify=true in NewClientFactoryFromConfig, which made this rebuild a pool reset and nothing more. That hardcode is gone: the factory reads the real IgnoreSslErrors setting, so with the setting off and --ignore-ssl-errors passed, spinnerRoundTripper.Next genuinely goes from a verifying transport to a non-verifying one. The comment you asked for was added (aaaf8a6) and has since been removed as stale, since it now describes the opposite of what happens.
Also in 884abe2: ConfigureHttpClient ORs the IgnoreSslErrors config key into its parameter, so the standing opt-out applies to login too. Without that, login would have been the one command that still verified when the user had opted out globally — a confusing failure, because login is exactly the command you run first. Covered by TestConfigureHttpClient/"honours the IgnoreSslErrors config key without the flag" and .../"verifies the server certificate by default".
(2) — left as is, with the comment. The in-place mutation is deliberate and the code now says so:
// Note that this mutates the factory's shared client rather than cloning it, so
// --ignore-ssl-errors outlives the login probe and applies to every later request
// in the process: fine for a one-shot CLI, a trap for any longer-lived embedding.Cloning would be the defensive fix, but it is not free: testLogin builds a ClientFactory from this same client and the spinner round-tripper is shared state by design, so a clone changes what the spinner sees and what TestConfigureHttpClient can assert about identity. It also buys nothing today — main() exits after one command. My read is that this stays as it is until something actually embeds the CLI, at which point the whole factory-returns-a-shared-client design needs the look, not just this line. Happy to be wrong if you know of an embedding: is there one?
|
|
||
| // parseProxyUrl mirrors how net/http parses a proxy address: a bare "host:port" | ||
| // is treated as http. | ||
| func parseProxyUrl(rawUrl string) (*url.URL, error) { |
There was a problem hiding this comment.
parseProxyUrl re-implements the private parseProxy in golang.org/x/net/http/httpproxy (same err != nil || Scheme == "" || Host == "" + "http://"+addr fallback), but the parsed result is then discarded and the raw string is handed to httpproxy to parse again with its own copy of the rules. They match today; if either side changes (e.g. httpproxy tightens scheme handling), validation here and actual resolution there can drift apart — a url this function accepts could be silently ignored by httpproxy, which is exactly the failure the comment above says this validation exists to prevent. Consider assigning the parsed (normalized) url into config.HTTPProxy/HTTPSProxy via .String() so one parse is authoritative.
There was a problem hiding this comment.
Actioned in 1b92f89, taking the suggested shape: the parsed url is now what httpproxy gets.
parsed, err := parseProxyUrl(s.Url)
if err != nil {
return nil, err
}
config.HTTPProxy = parsed.String()
config.HTTPSProxy = parsed.String()So the double parse is still there, but it is no longer two independent decisions about the same string: httpproxy parses a url that has already been normalized (scheme filled in), and the drift you describe — we accept host:port, httpproxy rejects it and silently connects direct — can't happen, because httpproxy never sees the un-schemed form.
On the dependency: golang.org/x/net was already in go.mod as an indirect dependency at v0.57.0, so using httpproxy costs nothing but promoting it to direct, which is the one-line go.mod change in this PR. No new module.
Residual I did not fix: parseProxy is still private, so the fallback rule (err != nil || Scheme == "" || Host == "" → prepend http://) is still duplicated as a rule, just no longer applied twice to the same input. Removing the duplication entirely would mean either vendoring the function or dropping httpproxy and reimplementing NO_PROXY matching, which is the part of that package genuinely worth having.
Standard HTTP_PROXY/HTTPS_PROXY/NO_PROXY already worked for API traffic because the transport chain ends at http.DefaultTransport, but `octopus login --ignore-ssl-errors` built a bare http.Transport that dropped proxy support (and panicked when the client already had one). Adds an OCTOPUS_PROXY environment variable and matching ProxyUrl config key, which override HTTP_PROXY/HTTPS_PROXY for both schemes while still honouring NO_PROXY. Credentials may be embedded in the url or supplied via OCTOPUS_PROXY_USERNAME/OCTOPUS_PROXY_PASSWORD, which are read from the environment only so a password is never written to the config file, and are redacted in `config list`. socks5 comes free from net/http. Refs #49 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An invalid OCTOPUS_PROXY/ProxyUrl was reported by interpolating the raw string into the error, and by wrapping url.Parse's *url.Error, which repeats the whole url again. Both paths printed an embedded password to the terminal and to CI logs. Redact the userinfo and unwrap the *url.Error before reporting. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
parseProxyUrl validated the configured url and then threw the result away, leaving httpproxy to parse the raw string again with its own copy of the same rules. Pass the normalized url through instead, so the two cannot drift into accepting here and ignoring there. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
OCTOPUS_PROXY_PASSWORD with no OCTOPUS_PROXY_USERNAME (unset, or a typo'd variable name) dropped the credentials silently and the user got a bare 407 from the proxy with no hint that the CLI had ignored them. Fail with a clear message instead, and only when a proxy is actually resolved and carries no credentials of its own, so a stray variable cannot break a direct connection. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The old code applied the ssl override to any non-nil client, building a
transport when the client had none. The rewrite only handled
*SpinnerRoundTripper, so a factory returning a plain &http.Client{} got
--ignore-ssl-errors silently ignored and no proxy. Build the proxy-aware
transport for that case too, and note in the comments that the spinner
branch is a no-op while the factory hardcodes insecureSkipVerify, and
that it mutates the factory's shared client.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
config get printed the stored ProxyUrl raw, including any embedded user:password, which contradicted the redaction 'config list' applies to the same key. ProxyUrl is new in this change, so nothing depends on the raw value being readable back; the file itself is still there for anyone who needs it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both login paths always write AccessToken, and ShowOctopus is settable, but neither had a case in the output switch, so any config file containing them fell through to "the key '%s' is not a supported config option" and printed nothing. AccessToken is masked above already, so it lists as *** like ApiKey. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The reviewer is right that the CLI never verifies the Octopus certificate, but that predates this change and flipping it here would break self-signed installs with no way to opt out. Say so at the call site so the next reader does not have to rediscover it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The previous wording implied no proxy password can reach the config file, but one embedded in ProxyUrl does. Say which of the two is stored, that display is masked, and that the password variable needs the username variable. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The CLI set InsecureSkipVerify on the shared http.DefaultTransport unconditionally, so it never verified the Octopus Server's TLS certificate: --ignore-ssl-errors was effectively always on and anything on the network path could read the API key sent with every request. Verification is now on, and turning it off is an explicit choice: the new IgnoreSslErrors config key, its OCTOPUS_IGNORE_SSL_ERRORS environment variable, or 'octopus login --ignore-ssl-errors' for a single login. login honours the config key as well as its own flag so it is not the odd command out. This is a deliberate behaviour change. Anyone relying on the old behaviour, typically a self-signed certificate, now gets a certificate error until they add the CA to the trust store or opt out. 'config set' rejects a non-boolean IgnoreSslErrors value rather than storing something viper would read back as false, and the key is listed by 'config list -f json' and offered by the 'config get'/'config set' pickers. Covered by TestNewClientFactoryFromConfig_TlsVerification, TestConfigureHttpClient's two new subtests, and the two new config.Setup tests. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
#726 shipped in v2.25.1 while this branch was open, and the two disagreed: this branch left an unrecognised transport alone, #726 refuses with an error rather than quietly leaving verification on after the caller asked for the opposite. #726's behaviour wins. ConfigureHttpClient now switches on the transport the way #726's skipTlsVerification did - spinner wrapper, plain transport, or an error - but builds the replacement through NewHttpTransport, so the ssl override stays proxy-aware. skipTlsVerification and its internal test go with it; the cases they covered are now in TestConfigureHttpClient. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
884abe2 to
0d709c4
Compare
Refs #49
Baseline: standard proxy env vars already work
Before adding anything, I checked whether the CLI loses Go's built-in proxy support. It does not, for normal API traffic.
pkg/apiclient/client_factory.go:129builds the http client withNewSpinnerRoundTripper(ask)pkg/apiclient/spinner_round_tripper.go:19setsNext: http.DefaultTransporthttp.DefaultTransport.Proxyishttp.ProxyFromEnvironmentSo
HTTP_PROXY,HTTPS_PROXYandNO_PROXYhave always been honoured for every Octopus API call. If that is all a customer needs, no CLI change was ever required. That reframes the issue: this is not "add proxy support", it is "add explicit configuration and close two gaps".The two real gaps
octopus login --ignore-ssl-errorslost the proxy.pkg/cmd/login/login.go:131built a bare&http.Transport{}, whoseProxyfield is nil — proxy support silently gone for exactly the command a new user runs first.httpClient.Transport.(*http.Transport)is an unchecked type assertion. When the CLI is already configured,f.GetHttpClient()returns the client whose transport is a*SpinnerRoundTripper, sooctopus login --ignore-ssl-errorscrashed withinterface conversion. Reproducible before this change; covered by a test now.What changed
New
pkg/apiclient/proxy.go:ProxySettings+ProxySettingsFromConfig()— reads the config/envProxyFunc()— resolution built ongolang.org/x/net/http/httpproxy(the same packagenet/httpuses), soNO_PROXYsemantics match the standard library exactlyNewHttpTransport(settings, insecureSkipVerify)— cloneshttp.DefaultTransportinstead of mutating it, keeping every standard default (including proxy) and no longer poisoning the process-wide transportRedactProxyUrl()— for displayWiring:
OCTOPUS_PROXYenv var +ProxyUrlconfig key (both were already stubbed out in commented-out code acrossconstants.go,config.go,config get,config set— this uncomments and completes them), plusconfig listsupport with redaction, and a fixedloginpath.Precedence
OCTOPUS_PROXYProxyUrlincli_config.jsonoctopus config set ProxyUrl ...HTTPS_PROXY/HTTP_PROXYAn explicit
OCTOPUS_PROXY/ProxyUrlapplies to both http and https requests (it replaces both env vars).NO_PROXYis honoured in every case, including over an explicit setting. Loopback targets are never proxied (standard Go behaviour, and what you want against a local Octopus).Credentials
user:pass@hostin the url works. Separately,OCTOPUS_PROXY_USERNAME/OCTOPUS_PROXY_PASSWORDapply to whichever proxy url was resolved — including one fromHTTPS_PROXY— and lose to credentials already in the url.Deliberately environment-only: they are read with
os.Getenv, not bound into viper, so they cannot be persisted tocli_config.jsonin plain text.config listredacts any password inProxyUrlviaurl.Redacted()(http://octo:xxxxx@proxy:3128), matching howApiKey/AccessTokenare already masked atpkg/cmd/config/list/list.go:38-44. The password is never logged or echoed.Out of scope, with reasons
http.Transportonly does Basic proxy auth. It would mean a third-party dependency (e.g.Azure/go-ntlmssp) doing a 3-leg handshake with connection affinity, plus SSPI for transparent single-sign-on on Windows. Real work, a supply-chain decision, and no test story without a Windows domain. Recommend a separate issue, driven by an actual customer request.net/http's transport dialssocks5://andsocks5h://proxy urls itself (socks_bundle.go,transport.go:1835). No extra dependency, no extra code.OCTOPUS_PROXY=socks5://host:1080works and is covered by a test.Test evidence
go build ./...clean.go test ./pkg/...all green (go vetreports 4 pre-existing "unreachable code" hits in unrelated files).pkg/apiclient/proxy_test.go— 14-case table over proxy resolution: no config,HTTP_PROXY/HTTPS_PROXYper scheme, explicit config overriding env, scheme-lesshost:port, socks5,NO_PROXYagainst both explicit and env proxies, loopback, and the three credential paths. Plus an invalid-url error case,ProxySettingsFromConfig, and aRedactProxyUrltable asserting the password never survives.TestNewHttpTransport_SendsRequestsThroughTheProxystands up anhttptestserver as the proxy and asserts the absolute-form request URI and theProxy-Authorization: Basicheader arrive at it.TestNewHttpTransport_LeavesTheDefaultTransportAloneguards the shared-transport mutation regression.pkg/cmd/login/login_test.go—TestConfigureHttpClientcovers all three branches, including the one that used to panic.pkg/config/config_test.go— provesOCTOPUS_PROXYis actually bound toProxyUrl.Every test is hermetic;
clearProxyEnvironmentstops the CI machine's own proxy settings leaking in.Open questions / options
1. Is an explicit setting wanted at all, or is env-only enough?
Since
HTTPS_PROXYalready worked,OCTOPUS_PROXYbuys one thing: pointing the CLI at a proxy without redirecting every other tool on the box. That is genuinely useful in CI, but it is new surface to document and support. Recommend keeping it — it is the thing the issue actually asks for, and it is cheap.2. No
--proxyflag, and there is a concrete reason.--proxyis already taken:pkg/machinescommon/proxy.go:15registers it ontarget ssh create,target listening-tentacle createand the worker equivalents, where it names an Octopus proxy resource. A root persistent--proxywould be shadowed by the local flag on exactly those commands — confusing for two different meanings of the word. Second obstacle: the client factory is built incmd/octopus/main.go:53, before cobra parses flags (the same ordering the spinner round-tripper comments call out), so a flag needs either lazy per-request resolution or a reordering. Options: (a) ship env/config only — my recommendation for this PR; (b) add--proxy-urlwith lazy resolution, ~10 lines on top of this; (c) reorder factory construction. Happy to do (b) if the team wants a flag.3. Credential env var names. The issue says
PROXY_USERNAME/PROXY_PASSWORD; I usedOCTOPUS_PROXY_USERNAME/OCTOPUS_PROXY_PASSWORDto match every otherOCTOPUS_*var. Unprefixed names risk colliding with other tooling. Easy to also accept the unprefixed names as a fallback if there is a compatibility reason.4. Should
ProxyUrlaccept credentials at all? It can today, andconfig listredacts it — but the password still sits incli_config.jsonin plain text, same asApiKeydoes. Alternative: reject a url containing a password onconfig setand force the env vars. Slightly more secure, slightly more annoying. Want that?5. Test matrix — what squid in Docker would add. The unit tests cover resolution and one real proxy hop, but not:
CONNECTtunnelling for https targets (thehttptestproxy sees an absolute URI, not aCONNECT), a 407 challenge/response round, proxies that mangle or buffer chunked responses, and TLS-terminating proxies with a corporate root CA. A squid container in CI would cover the first three; the fourth needs a generated CA and is where real customer pain usually lives. Suggest one squid-based integration test (anonymous + basic-auth) in the existing integration suite, kept out of the unit run. Worth noting the integration suite has its own CI problems today, so I did not add anything that depends on it.6. Unrelated but worth flagging:
pkg/apiclient/client_factory.go:124(before this change) setInsecureSkipVerify: trueunconditionally on the globalhttp.DefaultTransport— the CLI never verifies Octopus's TLS certificate, andlogin --ignore-ssl-errorsis effectively always on. I preserved the behaviour rather than change it in a proxy PR (it is now scoped to the CLI's own transport instead of the whole process), but it looks like a security bug and deserves its own issue.🤖 Generated with Claude Code